React Hooks 踩坑总结

LeoKnight
March 12th, 2020

为什么会有 Hooks?

随着 React Hooks 在正式版本的实装,Hooks 使 React 以一种全新的编程范式定义了前端开发约束,它为视图开发带来了一种全新的心智模型。 在 hooks 被引入之前,React 的设计理念是这样的

  • React 认为,UI视图是数据的一种视觉映射,即 UI = F(DATA),这里的F需要负责对输入数据进行加工、并对数据的变更做出响应。
  • 公式里的F在 React 里抽象成组件,React 是以组件Component-Based为粒度编排应用的,组件是代码复用的最小单元。
  • 在设计上,React 采用 props 属性来接收外部的数据,使用 state 属性来管理组件自身产生的数据(状态),而为了实现运行时对数据变更做出响应需要,React采用基于类Class的组件设计。
  • React 认为组件是有生命周期的,因此开创性地将生命周期的概念引入到了组件设计,从组件的 create 到 destory 提供了一系列生命周期钩子供开发者使用。 一个 Component-Based 的组件是长这样的:
1// React基于Class设计组件
2class MyConponent extends React.Component {
3 // 组件自身产生的数据
4 state = {
5 counts: 0
6 }
7
8 // 响应数据变更
9 clickHandle = () => {
10 this.setState({ counts: this.state.counts++ });
11 if (this.props.onClick) this.props.onClick();
12 }
13
14 // lifecycle API
15 componentWillUnmount() {
16 console.log('Will mouned!');
17 }
18
19 // lifecycle API
20 componentDidMount() {
21 console.log('Did mouned!');
22 }
23
24 // 接收外来数据(或加工处理),并编排数据在视觉上的呈现
25 render(props) {
26 return (
27 <>
28 <div>Input content: {props.content}, btn click counts: {this.state.counts}</div>
29 <button onClick={this.clickHandle}>Add</button>
30 </>
31 );
32 }
33}

Class Component 的问题

组件复用困局

组件并不是单纯的信息孤岛,组件之间是可能会产生联系的,一方面是数据的共享,另一个是功能的复用:

  • 对于组件之间的数据共享问题,React官方采用单向数据流Flux来解决
  • 对于(有状态)组件的复用,React团队给出过许多的方案。从早起的 CreateClass + Mixins,到后来设计了Render Props + Higher Order Component,之后现在的Function Component + Hooks的设计 HOC 的缺陷:
  • 嵌套地狱,每一次 HOC 调用都会产生一个组件实例
  • 可以使用类装饰器缓解组件嵌套带来的可维护性问题,但装饰器本质上还是 HOC
  • 包裹太多层级之后,可能会带来 props 属性的覆盖问题 Render Props 的缺陷:
  • 数据流向更直观了,子孙组件可以很明确地看到数据来源
  • 但本质上Render Props是基于闭包实现的,大量地用于组件的复用将不可避免地引入了callback hell问题
  • 丢失了组件的上下文,因此没有 this.props 属性,不能像 HOC 那样访问 this.props.children

Javascript Class 的缺陷

  1. this 指向问题
1class People extends Component {
2 state = {
3 name: 'dm',
4 age: 18,
5 }
6
7 handleClick(e) {
8 // 报错!
9 console.log(this.state);
10 }
11
12 render() {
13 const { name, age } = this.state;
14 return (<div onClick={this.handleClick}>My name is {name}, i am {age} years old.</div>);
15 }
16}

createClass 不需要处理 this 的指向,到了 Class Component 稍微不慎就会出现因 this 的指向报错。 2. 编译size(还有性能)问题:

1// Class Component
2class App extends Component {
3 state = {
4 count: 0
5 }
6
7 componentDidMount() {
8 console.log('Did mount!');
9 }
10
11 increaseCount = () => {
12 this.setState({ count: this.state.count + 1 });
13 }
14
15 decreaseCount = () => {
16 this.setState({ count: this.state.count - 1 });
17 }
18
19 render() {
20 return (
21 <>
22 <h1>Counter</h1>
23 <div>Current count: {this.state.count}</div>
24 <p>
25 <button onClick={this.increaseCount}>Increase</button>
26 <button onClick={this.decreaseCount}>Decrease</button>
27 </p>
28 </>
29 );
30 }
31}
32
33// Function Component
34function App() {
35 const [ count, setCount ] = useState(0);
36 const increaseCount = () => setCount(count + 1);
37 const decreaseCount = () => setCount(count - 1);
38
39 useEffect(() => {
40 console.log('Did mount!');
41 }, []);
42
43 return (
44 <>
45 <h1>Counter</h1>
46 <div>Current count: {count}</div>
47 <p>
48 <button onClick={increaseCount}>Increase</button>
49 <button onClick={decreaseCount}>Decrease</button>
50 </p>
51 </>
52 );
53}

Class Component 编译结果:

1var App_App = function (_Component) {
2 Object(inherits["a"])(App, _Component);
3
4 function App() {
5 var _getPrototypeOf2;
6 var _this;
7 Object(classCallCheck["a"])(this, App);
8 for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
9 args[_key] = arguments[_key];
10 }
11 _this = Object(possibleConstructorReturn["a"])(this, (_getPrototypeOf2 = Object(getPrototypeOf["a"])(App)).call.apply(_getPrototypeOf2, [this].concat(args)));
12 _this.state = {
13 count: 0
14 };
15 _this.increaseCount = function () {
16 _this.setState({
17 count: _this.state.count + 1
18 });
19 };
20 _this.decreaseCount = function () {
21 _this.setState({
22 count: _this.state.count - 1
23 });
24 };
25 return _this;
26 }
27 Object(createClass["a"])(App, [{
28 key: "componentDidMount",
29 value: function componentDidMount() {
30 console.log('Did mount!');
31 }
32 }, {
33 key: "render",
34 value: function render() {
35 return react_default.a.createElement(/*...*/);
36 }
37 }]);
38 return App;
39}(react["Component"]);

Function Component编译结果:

1function App() {
2 var _useState = Object(react["useState"])(0),
3 _useState2 = Object(slicedToArray["a" /* default */ ])(_useState, 2),
4 count = _useState2[0],
5 setCount = _useState2[1];
6 var increaseCount = function increaseCount() {
7 return setCount(count + 1);
8 };
9 var decreaseCount = function decreaseCount() {
10 return setCount(count - 1);
11 };
12 Object(react["useEffect"])(function () {
13 console.log('Did mount!');
14 }, []);
15 return react_default.a.createElement();
16}
  • Javascript实现的类本身比较鸡肋,没有类似Java/C++多继承的概念,类的逻辑复用是个问题
  • Class Component 在 React 内部是当做 Javascript Function 类来处理的
  • Function Component 编译后就是一个普通的 function,function对js引擎是友好的

Function Component缺失的功能

不是所有组件都需要处理生命周期,在React发布之初Function Component被设计了出来,用于简化只有render时Class Component的写法。

  • Function Component是纯函数,利于组件复用和测试。
  • Function Component的问题是只是单纯地接收props、绑定事件、返回jsx,本身是无状态的组件,依赖props传入的handle来响应数据(状态)的变更,所以Function Component不能脱离Class Comnent来存在。
1function Child(props) {
2 const handleClick = () => {
3 this.props.setCounts(this.props.counts);
4 };
5
6 // UI的变更只能通过Parent Component更新props来做到!!
7 return (
8 <>
9 <div>{this.props.counts}</div>
10 <button onClick={handleClick}>increase counts</button>
11 </>
12 );
13}
14
15class Parent extends Component() {
16 // 状态管理还是得依赖Class Component
17 counts = 0
18
19 render () {
20 const counts = this.state.counts;
21 return (
22 <>
23 <div>sth...</div>
24 <Child counts={counts} setCounts={(x) => this.setState({counts: counts++})} />
25 </>
26 );
27 }
28}

所以,Function Comonent是否能脱离Class Component独立存在,关键在于让Function Comonent自身具备状态处理能力,即在组件首次render之后,“组件自身能够通过某种机制再触发状态的变更并且引起re-render”,而这种“机制”就是Hooks! Hooks的出现弥补了Function Component相对于Class Component的不足,让Function Component取代Class Component成为可能。 项目中也实践了很多hooks,但不成熟的使用方式会导致很多诡异的 bug,在此记录一下在踩过的坑和解决方案。

在用 Hooks 之前你需要做什么?

  1. 仔细阅读 React Hooks 官方文档
  2. 工程引入 hooks 相关 lint,开启规则

lint插件:https://www.npmjs.com/package/eslint-plugin-react-hooks

1{
2 "plugins": ["react-hooks"],
3 "rules": {
4 "react-hooks/rules-of-hooks": "error",
5 "react-hooks/exhaustive-deps": "warn"
6 }
7}

其中, react-hooks/exhaustive-deps 至少warn,也可以是error。建议全新的工程直接配”error”,历史工程配”warn”。 3. 如若有发现hooks相关lint导致的warning,不要全局autofix 除了hooks外,正常的lint基本不会改变代码逻辑,只是调整编写规范。但是hooks的lint规则不同, exhaustive-deps 的变化会导致代码逻辑发生变化,这极容易引发线上问题,所以对于hooks的waning,请不要做全局autofix操作。除非保证每处逻辑都做到了充分回归。 建议开启vscode的「autofix on save」。这样能把error与warning遏制在开发阶段,保证自测跟测试时就是符合规则的代码。

依赖问题

依赖与闭包问题是一定要开启exhaustive-deps 的核心原因。最常见的错误即:mount时绑定事件,后续状态更新出错。 错误代码示例:(此处用addEventListener做onclick绑定,只是为了方便说明情况)

1function ErrorDemo() {
2 const [count, setCount] = useState(0);
3 const dom = useRef(null);
4 useEffect(() => {
5 dom.current.addEventListener('click', () => setCount(count + 1));
6 }, []);
7 return <div ref={dom}>{count}</div>;
8}

这段代码的初始想法是:每当用户点击dom,count就加1。理想中的效果是一直点,一直加。但实际效果是 {count} 到「1」以后就加不上了。

我们来梳理一下, useEffect(fn, []) 代表只会在mount时触发。也即是首次render时,fn执行一次,绑定了点击事件,点击触发 setCount(count + 1) 。乍一想,count还是那个count,肯定会一直加上去呀,当然现实在啪啪打脸。

状态变更 触发 页面渲染的本质是什么?本质就是 ui = fn(props, state, context) 。props、内部状态、上下文的变更,都会导致渲染函数(此处就是ErrorDemo)的重新执行,然后返回新的view。

那现在问题来了, ErrorDemo 这个函数执行了多次,第一次函数内部的 count 跟后面几次的 count 会有关系吗?这么一想,感觉又应该没有关系了。那为什么 第二次又知道 count 是1,而不是0了呢?第一次的 setCount 跟后面的是同一个函数吗?这背后涉及到hooks的一些底层原理,也关系到了为什么hooks的声明需要声明在函数顶部,不允许在条件语句中声明。在这里就不多讲了。

结论是:每次 count 都是重新声明的变量,指向一个全新的数据;每次的 setCount 虽然是重新声明的,但指向的是同一个引用。

回到正题,我们知道了每次render,内部的count其实都是全新的一个变量。那我们绑定的点击事件方法,也即:setCount(count + 1) ,这里的count,其实指的一直是首次render时的那个count,所以一直是0 ,因此 setCount,一直是设置count为1。

那这个问题怎么解?

首先,应该遵守前面的硬性要求,必须要加lint规则,并开启autofix on save。然后就会发现,其实这个 effect 是依赖 count 的。autofix 会帮你自动补上依赖,代码变成这样:

1useEffect(() => {
2 dom.current.addEventListener('click', () => setCount(count + 1));
3}, [count]);

那这样肯定就不对了,相当于每次count变化,都会重新绑定一次事件。所以对于事件的绑定,或者类似的场景,有几种思路,我按我的常规处理优先级排列:

  1. 消除依赖 在这个场景里,很简单,我们主要利用 setCount 的另一个用法 functional updates。这样写就好了:() => setCount(prevCount => ++prevCount) ,不用关心什么新的旧的、什么闭包,省心省事。
  2. 重新绑定事件 那如果我们这个事件就是要消费这个count怎么办?比如这样:
1dom.current.addEventListener('click', () => {
2 console.log(count);
3 setCount(prevCount => ++prevCount);
4});

我们不必执着于一定只在mount时执行一次。也可以每次重新render前移除事件,render后绑定事件即可。这里利用useEffect的特性,具体可以自己看文档:

1useEffect(() => {
2 const $dom = dom.current;
3 const event = () => {
4 console.log(count);
5 setCount(prev => ++prev);
6 };
7 $dom.addEventListener('click', event);
8 return () => $dom.removeEventListener('click', event);
9}, [count]);
  1. 如果嫌这样开销大,或者编写麻烦,也可以用 useRef, 其实用 useRef 也挺麻烦的,我个人不太喜欢这样操作,但也能解决问题,代码如下:
1const [count, setCount] = useState(0);
2const countRef = useRef(count);
3useEffect(() => {
4 dom.current.addEventListener('click', () => {
5 console.log(countRef.current);
6 setCount(prevCount => {
7 const newCount = ++prevCount;
8 countRef.current = newCount;
9 return newCount;
10 });
11 });
12}, []);

useCallback 与 useMemo

这两个api,其实概念上还是很好理解的,一个是「缓存函数」, 一个是缓存「函数的返回值」。但我们经常会懒得用,甚至有的时候会用错。

从上面依赖问题我们其实可以知道,hooks对「有没有变化」这个点其实很敏感。如果一个effect内部使用了某数据或者方法。若我们依赖项不加上它,那很容易由于闭包问题,导致数据或方法,都不是我们理想中的那个它。如果我们加上它,很可能又会由于他们的变动,导致effect疯狂的执行。真实开发的话,大家应该会经常遇到这种问题。

所以,在此建议:

  1. 在组件内部,那些会成为其他useEffect依赖项的方法,建议用 useCallback 包裹,或者直接编写在引用它的useEffect中。
  2. 己所不欲勿施于人,如果你的function会作为props传递给子组件,请一定要使用 useCallback 包裹,对于子组件来说,如果每次render都会导致你传递的函数发生变化,可能会对它造成非常大的困扰。同时也不利于react做渲染优化。 不过还有一种场景,大家很容易忽视,而且还很容易将useCallback与useMemo混淆,典型场景就是:节流防抖。 举个例子:
1function BadDemo() {
2 const [count, setCount] = useState(1);
3 const handleClick = debounce(() => {
4 setCount(c => ++c);
5 }, 1000);
6 return <div onClick={handleClick}>{count}</div>;
7}

我们希望防止用户连续点击触发多次变更,加了个防抖,停止点击1秒后才触发 count + 1 ,这个组件在理想逻辑下是OK的。但现实是骨感的,我们的页面组件非常多,这个 BadDemo 可能由于父级什么操作就重新render了。现在假使我们页面每500毫秒会重新render一次,那么就是这样:

1function BadDemo() {
2 const [count, setCount] = useState(1);
3 const [, setRerender] = useState(false);
4 const handleClick = debounce(() => {
5 setCount(c => ++c);
6 }, 1000);
7 useEffect(() => {
8 // 每500ms,组件重新render
9 window.setInterval(() => {
10 setRerender(r => !r);
11 }, 500);
12 }, []);
13 return <div onClick={handleClick}>{count}</div>;
14}

每次render导致handleClick其实是不同的函数,那么这个防抖自然而然就失效了。这样的情况对于一些防重点要求特别高的场景,是有着较大的线上风险的。 那怎么办呢?自然是想加上 useCallback :

1const handleClick = useCallback(debounce(() => {
2 setCount(c => ++c);
3}, 1000), []);

现在我们发现效果满足我们期望了,但这背后还藏着一个惊天大坑。 假如说,这个防抖的函数有一些依赖呢?比如setCount(c => ++c); 变成了 setCount(count + 1) 。那这个函数就依赖了 count 。代码就变成了这样:

1const handleClick = useCallback(
2 debounce(() => {
3 setCount(count + 1);
4 }, 1000),
5 []
6);

大家会发现,你的lint规则,竟然不会要求你把 count 作为依赖项,填充到deps数组中去。这进而导致了最初的那个问题,只有第一次点击会count++。这是为什么呢?

因为传入useCallback的是一段执行语句,而不是一个函数声明。只是说它执行以后返回的新函数,我们将其作为了 useCallback 函数的入参,而这个新函数具体是个啥,其实lint规则也不知道。

更合理的姿势应该是使用 useMemo :

1const handleClick = useMemo(
2 () => debounce(() => {
3 setCount(count + 1);
4 }, 1000),
5 [count]
6);

这样保证每当 count 发生变化时,会返回一个新的加了防抖功能的新函数。

思考

  • React是如何识别纯函数组件和类组件的?

欢迎订阅我的频道

输入邮箱您将加入到我的邮箱组中,当有文章更新时,您将第一时间得到推送,保护隐私是我的做人准则,您的邮箱不会被第三方获取。

More articles from LeoKnight